<!doctype html>

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Draggable Cards</title>
  <style>
    body {
      font: 16px system-ui;
      user-select: none;
      /* no text selection needed */
      -webkit-user-select: none;
      /* no text selection needed */
      background-color: #eee;
      overflow: hidden;
      /* prevent accidentally dragging the viewport on iOS */
      margin: 0;
    }

    .row {
      position: absolute;
      display: flex;
      place-items: center;
      overflow: hidden;
      /* height might be small during animation */
      transition:
        box-shadow 0.25s ease-out,
        opacity 0.25s ease-out;
    }
  </style>
</head>

<body>
  <script type="module">
    console.log("import.meta.url: ",import.meta.url);
  </script>
  <div id="nox" style="height: 320px; width: 320px;"></div>
  <script>
    "use strict";

    // === generic scheduler & its debugger
    let scheduledRender = false;
    function scheduleRender(debugForceRender) {
      if (scheduledRender) return;
      scheduledRender = true;

      requestAnimationFrame(function renderAndMaybeScheduleAnotherRender(now) {
        // eye-grabbing name. No "(anonymous)" function in the debugger & profiler
        scheduledRender = false;
        if (render(now)) scheduleRender();
      });
    }

    // === generic spring physics
    // 4ms/step for the spring animation's step. Typically 4 steps for 60fps (16.6ms/frame) and 2 for 120fps (8.3ms/frame). Frame time delta varies, so not always true
    // could use 8ms instead, but 120fps' 8.3ms/frame means the computation might not fit in the remaining 0.3ms, which means sometime the simulation step wouldn't even run once, giving the illusion of jank
    const msPerAnimationStep = 4;
    function spring(pos, v = 0, k = 290, b = 24) {
      return { pos, dest: pos, v, k, b }; // k = stiffness, b = damping. Try https://chenglou.me/react-motion/demos/demo5-spring-parameters-chooser/
    }
    function springStep(config) {
      // https://blog.maximeheckel.com/posts/the-physics-behind-spring-animations/
      // this seems inspired by https://github.com/chenglou/react-motion/blob/9e3ce95bacaa9a1b259f969870a21c727232cc68/src/stepper.js
      const t = msPerAnimationStep / 1000; // convert to seconds for the physics equation
      const { pos, dest, v, k, b } = config;
      // for animations, dest is actually spring at rest. Current position is the spring's stretched/compressed state
      const Fspring = -k * (pos - dest); // Spring stiffness, in kg / s^2
      const Fdamper = -b * v; // Damping, in kg / s
      const a = Fspring + Fdamper; // a needs to be divided by mass, but we'll assume mass of 1. Adjust k and b to change spring curve instead
      const newV = v + a * t;
      const newPos = pos + newV * t;

      config.pos = newPos;
      config.v = newV;
    }
    function springGoToEnd(config) {
      config.pos = config.dest;
      config.v = 0;
    }

    // === generic helper
    function center(containee, container) {
      return (container - containee) / 2;
    }
    
    // === constant layout metrics. The rest is dynamic
    const nox = document.getElementById("nox");
    const rowSizeX = 320;
    const windowPaddingTop = 50;

    // === state
    let animatedUntilTime = null;
    let dragged = null;
    let lastDragged = null;
    let inputs = {
      /** @type 'down' | 'up' | 'firstDown' */
      pointerState: "up",
      pointer: [{ x: 0, y: 0, time: 0 }], // circular buffer. Btw, on page load, there's no way to render a first cursor state =(
    };
    let data = [];
    {
      const windowSizeX = document.documentElement.clientWidth; // excludes scroll bar & invariant under safari pinch zoom
      for (let i = 0; i < 5; i++) {
        let node = document.createElement("div");
        const sizeY = 30; // [30, 180)
        node.className = "row";
        node.innerHTML = "Drag Me " + i;
        node.style.width = rowSizeX;
        node.style.height = sizeY;
        const colorRand = Math.random() * 40 + 40; // Range: [40, 80]
        node.style.outline = `1px solid hsl(205, 100%, ${colorRand}%)`; // blue hue
        node.style.backgroundColor = `hsl(205, 100%, ${colorRand + 10}%)`; // lighter blue hue
        data.push({
          id: i + "", // gonna drag rows around so we can't refer to a row by index. Assign a stable id
          sizeY,
          x: spring(center(rowSizeX, windowSizeX)),
          y: spring(0),
          scale: spring(1),
          node: node,
        });
        nox.appendChild(node);
      }
    }
    function springForEach(f) {
      for (let d of data) {
        f(d.x);
        f(d.y);
        f(d.scale); // no different than [a, b, c].forEach(f)
      }
    }
    // === events
    // pointermove doesn't work on android, pointerdown isn't fired on Safari on the first left click after dismissing context menus, mousedown doesn't trigger properly on mobile, pointerup isn't triggered when pointer panned (at least on iOS), don't forget contextmenu event. Tldr there's no pointer event that works cross-browser that can replace mouse & touch events.
    nox.addEventListener("resize", () => scheduleRender());
    nox.addEventListener("mouseup", (e) => {
      console.log("mouseup");
      inputs.pointerState = "up";
      scheduleRender();
    });
    nox.addEventListener("touchend", (e) => {
      console.log("touchend");
      inputs.pointerState = "up";
      scheduleRender();
    });
    nox.addEventListener("mousemove", (e) => {
      // when scrolling (which might schedule a render), a container's pointermove doesn't trigger, so the pointer's local coordinates are stale
      // this means we should only use pointer's global coordinates, which is always right
      inputs.pointer.push({ x: e.pageX, y: e.pageY, time: performance.now() });
      console.log('mousemove', /** @type {HTMLElement} */(e.target).tagName);
      // btw, pointer can exceed document bounds, e.g. dragging reports back out-of-bound, legal negative values
      scheduleRender();
    });
    nox.addEventListener("touchmove", (e) => {
      inputs.pointer.push({
        x: e.touches[0].pageX,
        y: e.touches[0].pageY,
        time: performance.now(),
      });
      scheduleRender();
    });
    nox.addEventListener("pointerdown", (e) => {
      inputs.pointerState = "firstDown";
      inputs.pointer.push({ x: e.pageX, y: e.pageY, time: performance.now() });
      scheduleRender();
    });

    // === hit testing logic. Boxes' hit area should be static and not follow their current animated state usually (but we can do either). Use the dynamic area here for once
    function hitTest(data, pointer) {
      for (let d of data) {
        let { x, y, sizeY } = d;
        if (
          x.pos <= pointer.x &&
          pointer.x < x.pos + rowSizeX &&
          y.pos <= pointer.y &&
          pointer.y < y.pos + sizeY
        )
          return d; // pointer on this box
      }
    }

    function render(now) {
      // === step 1: batched DOM reads (to avoid accidental DOM read & write interleaving)
      const windowSizeX = document.documentElement.clientWidth; // excludes scroll bar & invariant under safari pinch zoom
      let { pointer } = inputs;
      const pointerLast = pointer.at(-1); // guaranteed non-null since pointer.length >= 1

      // === step 2: handle inputs-related state change
      let newDragged;
      let releaseVelocity = null;
      if (inputs.pointerState === "down") newDragged = dragged;
      else if (inputs.pointerState === "up") {
        if (dragged != null) {
          let dragIdx = data.findIndex((d) => d.id === dragged.id);
          let i = pointer.length - 1;
          while (i >= 0 && now - pointer[i].time <= 100) i--; // only consider last ~100ms of movements
          let deltaTime = now - pointer[i].time;
          let vx = ((pointerLast.x - pointer[i].x) / deltaTime) * 1000; // speed over ~1s
          let vy = ((pointerLast.y - pointer[i].y) / deltaTime) * 1000;
          data[dragIdx].x.v += vx;
          data[dragIdx].y.v += vy;
        }
        newDragged = null;
      } else {
        const hit = hitTest(data, pointerLast);
        if (hit)
          newDragged = {
            id: hit.id,
            deltaX: pointerLast.x - hit.x.pos,
            deltaY: pointerLast.y - hit.y.pos,
          };
      }

      // === step 3: calculate new layout & cursor
      if (newDragged) {
        // first, swap row based on cursor position if needed
        let dragIdx = data.findIndex((d) => d.id === newDragged.id); // guaranteed non-null
        let d = data[dragIdx];
        const x = pointerLast.x - newDragged.deltaX;
        const y = pointerLast.y - newDragged.deltaY;
        d.x.pos = d.x.dest = x + (center(rowSizeX, windowSizeX) - x) / 1.5; // restrict horizontal drag a bit
        d.y.pos = d.y.dest = y;
        d.scale.dest = 1.1;
        // dragging row upward? Swap it with previous row if cursor is above the first half previous row
        while (
          dragIdx > 0 &&
          pointerLast.y < data[dragIdx - 1].y.dest + data[dragIdx - 1].sizeY / 2
        ) {
          [data[dragIdx], data[dragIdx - 1]] = [
            data[dragIdx - 1],
            data[dragIdx],
          ]; // swap
          dragIdx--;
        }
        // dragging row downward? Swap it with next row if cursor is below the first half next row
        while (
          dragIdx < data.length - 1 &&
          pointerLast.y > data[dragIdx + 1].y.dest + data[dragIdx + 1].sizeY / 2
        ) {
          [data[dragIdx], data[dragIdx + 1]] = [
            data[dragIdx + 1],
            data[dragIdx],
          ]; // swap
          dragIdx++;
        }
      }
      let top = windowPaddingTop;
      for (let d of data) {
        if (newDragged && d.id === newDragged.id) {
          // already modified above
        } else {
          d.x.dest = center(rowSizeX, windowSizeX);
          d.y.dest = top;
          d.scale.dest = 1;
        }
        top += d.sizeY;
      }
      const cursor = newDragged
        ? "grabbing" // will be "grabbing" even if cursor isn't on the card! Try dragging to left/right extremes
        : hitTest(data, pointerLast)
          ? "grab"
          : "auto";

      // === step 4: run animation
      let newAnimatedUntilTime = animatedUntilTime ?? now;
      const steps = Math.floor(
        (now - newAnimatedUntilTime) / msPerAnimationStep,
      ); // run x spring steps. Decouple physics simulation from framerate!
      newAnimatedUntilTime += steps * msPerAnimationStep;
      let stillAnimating = false;
      springForEach((s) => {
        for (let i = 0; i < steps; i++) springStep(s);
        if (Math.abs(s.v) < 0.01 && Math.abs(s.dest - s.pos) < 0.01)
          springGoToEnd(s); // close enough, done
        else stillAnimating = true;
      });

      // === step 5: render. Batch DOM writes
      for (let i = 0; i < data.length; i++) {
        let d = data[i],
          style = d.node.style;
        style.transform = `translate3d(${d.x.pos}px,${d.y.pos}px,0) scale(${d.scale.pos})`;
        style.zIndex =
          newDragged && d.id === newDragged.id
            ? data.length + 2
            : lastDragged && d.id === lastDragged.id
              ? data.length + 1 // last dragged and released row still needs to animate into place; keep it high
              : i;
        if (newDragged && d.id === newDragged.id) {
          style.boxShadow = "rgba(0, 0, 0, 0.2) 0px 16px 32px 0px";
          style.opacity = 0.7;
        } else {
          style.boxShadow = "rgba(0, 0, 0, 0.2) 0px 1px 2px 0px";
          style.opacity = 1.0;
        }
      }
      nox.style.cursor = cursor;

      // === step 6: update state & prepare for next frame
      if (inputs.pointerState === "firstDown") inputs.pointerState = "down";
      if (dragged && newDragged == null) lastDragged = dragged;
      dragged = newDragged;
      animatedUntilTime = stillAnimating ? newAnimatedUntilTime : null;
      if (inputs.pointerState === "up")
        inputs.pointer = [{ x: 0, y: 0, time: 0 }];
      if (inputs.pointer.length > 20) inputs.pointer.shift(); // keep last ~20

      return stillAnimating;
    }

    scheduleRender();
  </script>

</body>
